Skip to content

fix: preserve selected page state across the maintenance mode lifecycle - #524

Open
Alexia-Soare wants to merge 11 commits into
developmentfrom
fix/523-preserve-selected-page-state
Open

fix: preserve selected page state across the maintenance mode lifecycle#524
Alexia-Soare wants to merge 11 commits into
developmentfrom
fix/523-preserve-selected-page-state

Conversation

@Alexia-Soare

@Alexia-Soare Alexia-Soare commented Jul 17, 2026

Copy link
Copy Markdown

Selecting an existing published page (e.g. the homepage) as the maintenance page could permanently break it (#523): the plugin forced it private on every load with maintenance mode disabled, and applying a gallery template overwrote its content. The plugin now records a selected page's original state and restores it, and only writes templates into pages it created itself.

What changed

  • select_page() (or first enable, for pre-existing selections) records the page's original status and template in a single post meta value, claimed first-write-wins, so a racing request can neither capture plugin-made changes as "original" nor observe half a record. The record is cleared after each restore, so later user edits become the new baseline; a maintenance template carried over from an old release is recorded as "no template" rather than as user state.
  • Disabling maintenance mode used to force the page private; disabling and plugin deactivation now restore the recorded status and template, and leave pages with no record untouched.
  • Restore only reverts a status the plugin itself applied (its publish of a private page). A status the user chose while maintenance mode was active — draft, private, or trash — is kept, and the page just gets its template handed back and the record cleared. Enable likewise skips deleted and trashed pages entirely.
  • Changing or clearing the page selection restores the previously selected page before switching, so an abandoned page doesn't keep the maintenance template or a stale record.
  • Template imports only overwrite pages the plugin created (marked _wpmm_generated); a selected user-owned page is restored and the template lands on a newly created "Maintenance Page", which becomes the selection. The "content will be replaced" warning in settings now shows only in that overwrite case.
  • Template imports reject slugs/categories that don't resolve to a bundled template (previously created an empty page; both are sanitized before filesystem use), and the Otter CSS_Handler call is skipped when Otter Blocks isn't installed (previously a fatal error).

The CI job also moves from PHP 7.2 to 7.4: the latest WordPress test suite requires 7.4, so PHPUnit aborted before running any test. It's bundled here because this PR's tests depend on it.

Lifecycle after this PR

flowchart TD
    A[User selects a page] --> P[Restore previously<br/>selected page, if any]:::added
    P --> B[Record original status + template]:::added
    B --> C[Enable: apply wpmm template,<br/>publish page, mark publish as plugin-made]:::changed
    C --> D{Disable / deactivate}
    D --> E[Undo the plugin-made publish only,<br/>restore template, clear record;<br/>user status changes stay]:::added

    F[Template import] --> G{Page marked<br/>_wpmm_generated?}:::added
    G -- yes --> H[Overwrite it with the template]
    G -- no --> I[Restore user page; create new<br/>generated page and select it]:::added

    classDef added fill:#1a7f37,color:#fff,stroke:#1a7f37
    classDef changed fill:#9a6700,color:#fff,stroke:#9a6700
Loading

🟩 added by this PR (restore on switch, record/restore, ownership check, new-page branch) · 🟨 changed behavior (enable now records state, marks its publish, and skips deleted/trashed pages) · gray = unchanged

Data changes

Before: no stored record — disable ran post_status → private unconditionally. After: state is recorded on takeover and consumed on restore.

Meta key Value Owner / lifetime
_wpmm_original_state Status + template before the plugin touched the page, as one atomic value Plugin; takeover → restore
_wpmm_applied_status The status the plugin set (publish, when it publishes a private page) Plugin; publish → restore
_wpmm_generated 1 on pages the plugin created Plugin; permanent

QA

  1. Regression (Disabling maintenance mode makes the selected homepage private and returns 404 #523): select an existing published page as the maintenance page, enable maintenance mode, then disable it. The page stays public with its original template — previously it went private and visitors got a 404.
  2. With maintenance mode active and your page selected, switch the page to Draft in the editor, then disable maintenance mode. The page stays a draft instead of being republished.
  3. With page A selected, pick a different page B (or clear the selection) in Design → Select page. Page A returns to its original status and template; B becomes the maintenance page.
  4. Select a page, trash it, then deactivate the plugin and restore the page from the trash. It comes back without the maintenance template.
  5. With a user-owned page selected, open Design → Templates: no "content will be replaced" warning. Select a plugin-generated page: the warning appears.
  6. On a site without Otter Blocks, apply a gallery template: it applies without a fatal error.

Alexia-Soare and others added 5 commits July 16, 2026 16:55
…de lifecycle

Selecting an existing published page as the maintenance page no longer
leaves it private (404 for visitors) after maintenance mode is disabled
or the plugin is deactivated.

The page's original status and template are now recorded in post meta
when the page is selected (or on first enable, for pages selected before
this fix) and restored when maintenance mode is disabled and on plugin
deactivation. Pages with no recorded state are left untouched instead of
being forced private.

Fixes #523

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…plate

Applying a gallery template while an existing site page was selected
overwrote that page's content and forced it private. Templates are now
written only to pages the plugin created (marked with _wpmm_generated);
when a user-owned page is selected, it is handed back in its original
state and the template lands on a newly created maintenance page.

Recording a page's original state is now first-write-wins and the record
is cleared on restore, so re-selecting a page the plugin already modified
can no longer poison the saved original state.

Also guards the Otter CSS_Handler call, which fataled on sites without
Otter Blocks.

Refs #523

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Skip the enable-mode page takeover when the selected page no longer
exists (previously wrote orphan post meta), and reject template imports
whose slug/category do not resolve to a bundled template instead of
creating an empty maintenance page. Template slug and category are
sanitized before being used in a filesystem path.

Refs #523

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restoring the selected page's recorded state now leaves trashed pages
alone (trashing is always user intent; the plugin never trashes), and
enabling maintenance mode with a trashed page selected records nothing,
so untrashing the page later cannot snap it back to trash.

Refs #523

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Selecting an existing published page via the real AJAX handler, enabling
and disabling maintenance mode must leave the page public with its
original content and template. Verified to fail against the unfixed
code.

Refs #523

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pirate-bot

pirate-bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Plugin build for dab296c is ready 🛎️!

@Alexia-Soare Alexia-Soare added the pr-checklist-skip Allow this Pull Request to skip checklist. label Jul 17, 2026
@pirate-bot pirate-bot added the pr-checklist-complete The Pull Request checklist is complete. (automatic label) label Jul 17, 2026
The job installed the latest WordPress test suite (currently 7.0.1,
which requires PHP 7.4+) on PHP 7.2, so the suite aborted before running
any test. The phpunit:7.5.20 tool pin was unused: composer run-script
resolves vendor/bin/phpunit from the lockfile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread tests/page-state-test.php Outdated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Preserves selected maintenance-page state and prevents user-owned pages from being overwritten.

Changes:

  • Records and restores page status/template across maintenance lifecycle events.
  • Restricts template overwrites to plugin-generated pages and validates imports.
  • Adds lifecycle tests and updates CI to PHP 7.4.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
views/settings.php Limits overwrite warnings to generated pages.
tests/page-state-test.php Adds lifecycle and template-import tests.
includes/functions/helpers.php Adds page-state record/restore helpers.
includes/classes/wp-maintenance-mode.php Integrates restoration with enable, disable, and deactivation.
includes/classes/wp-maintenance-mode-admin.php Adds ownership-aware selection and template importing.
.github/workflows/test-php.yml Updates PHPUnit CI to PHP 7.4.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread includes/classes/wp-maintenance-mode-admin.php
Comment thread includes/functions/helpers.php
Comment thread includes/functions/helpers.php Outdated
Comment thread includes/classes/wp-maintenance-mode.php
…mically

Addresses the Copilot review on #524:
- select_page() restores the previously selected page before switching
  away from it, so the old page never keeps the maintenance template
- restoring a trashed page keeps it in the trash but hands back the
  template and clears the record, so untrashing brings it back clean
- recording claims the record with add_post_meta( ..., true ) so a
  racing request cannot capture the plugin-modified state as original

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

includes/classes/wp-maintenance-mode-admin.php:696

  • The previous page is restored while wpmm_settings still selects it until line 713. If maintenance mode is active, a frontend request in that interval boots from the old option and takes that page over again; this callback then commits the new ID, leaving the abandoned page with the maintenance template and stale state metadata. Commit/gate the selection transition before restoring the old page, or serialize it with frontend takeover.
			if ( $previous_page_id && $previous_page_id !== $page_id ) {
				// hand the previously selected page back before abandoning it,
				// otherwise nothing would ever restore it
				wpmm_restore_page_state( $previous_page_id );

includes/functions/helpers.php:542

  • add_post_meta(..., true) is not an atomic first-write-wins claim: WordPress checks for existing metadata before inserting, and wp_postmeta has no uniqueness constraint, so racing requests can both insert records. The template is also written separately afterward, allowing restore to consume a partial status-only record. Use storage with an actual unique constraint/compare-and-set or a lock, and persist status plus template atomically.
	// the unique flag makes the status key a first-write-wins claim on the
	// record, so a racing request cannot save the plugin-modified state
	if ( ! add_post_meta( $page_id, '_wpmm_original_status', $status, true ) ) {
		return;
	}

	update_post_meta( $page_id, '_wpmm_original_template', $template );

Comment thread includes/classes/wp-maintenance-mode-admin.php
Comment thread includes/classes/wp-maintenance-mode-admin.php
Alexia-Soare added a commit that referenced this pull request Jul 23, 2026
The legacy eslint toolchain caps its node engines below current node
versions while the new test tooling requires node 18+, so every yarn
install now runs with --ignore-engines and the zip/deploy workflows move
from node 14 to 20. The PHPUnit job moves to PHP 7.4, required by the
latest WordPress test suite (same fix as #524; trivial conflict).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Alexia-Soare
Alexia-Soare requested a review from Copilot July 28, 2026 12:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

includes/functions/helpers.php:542

  • The unique status write does not make the two-key state record atomic. A concurrent disable/switch can restore after _wpmm_original_status is added but before _wpmm_original_template is written, deleting the real template and clearing the claim; conversely, restore's separate deletes can remove a new takeover's record. Store status and template together in one first-write-wins meta value and consume that record atomically (or add equivalent synchronization).
	// the unique flag makes the status key a first-write-wins claim on the
	// record, so a racing request cannot save the plugin-modified state
	if ( ! add_post_meta( $page_id, '_wpmm_original_status', $status, true ) ) {
		return;
	}

	update_post_meta( $page_id, '_wpmm_original_template', $template );

Comment thread includes/functions/helpers.php Outdated
Alexia-Soare and others added 2 commits July 28, 2026 16:04
…ve-selected-page-state

# Conflicts:
#	.github/workflows/test-php.yml
wp_insert_post() without the wp_error flag returns an int, so the
instanceof WP_Error guard was dead code; the page-state helpers also
document their void return now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses the follow-up Copilot review on #524:
- the plugin marks the publish it applies to a private page, and restore
  only reverts the status while the page still carries that plugin-made
  publish; a draft/private/trash the user chose in the meantime wins
- the original state lives in a single first-write-wins meta value, so a
  racing request can neither poison the record nor observe half of it
- a maintenance template carried over from an old release is recorded as
  no template, so restore removes it instead of making it permanent

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

includes/classes/wp-maintenance-mode.php:110

  • A user-selected private status is not preserved here. After a page has been taken over, any later request while maintenance mode is active sees private and publishes it again; this also happens when the originally published page was made private by the user, because there is no applied-status marker yet. Gate publishing on the recorded original status being private and on publish not having already been applied, so later user changes are not undone.
					if ( get_post_status( $this->plugin_settings['design']['page_id'] ) === 'private' ) {

includes/classes/wp-maintenance-mode-admin.php:773

  • This condition excludes trashed pages because $page_exists is false for trash. If a selected user page is trashed and a gallery template is imported, the selection moves to the new page without restoring the old page, leaving its maintenance template and state metadata behind; restoring it from trash later exposes that stale plugin state. Restore any still-existing selected post here, including trash.
				if ( $page_exists ) {
					// hand the user's page back before switching to a generated one
					wpmm_restore_page_state( $selected_page_id );

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-checklist-complete The Pull Request checklist is complete. (automatic label) pr-checklist-skip Allow this Pull Request to skip checklist.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Disabling maintenance mode makes the selected homepage private and returns 404

4 participants